Skip to content

feat: Discord DM fallback when voice client is disconnected#347

Merged
sonichi merged 1 commit intomainfrom
feat/dm-result-fallback
Apr 15, 2026
Merged

feat: Discord DM fallback when voice client is disconnected#347
sonichi merged 1 commit intomainfrom
feat/dm-result-fallback

Conversation

@sonichi
Copy link
Copy Markdown
Owner

@sonichi sonichi commented Apr 15, 2026

Summary

  • New src/dm-result.py — checks voice client connection via /sse-status
  • If voice is disconnected, sends result to owner's Discord DM
  • If voice is connected, does nothing (voice agent speaks the result)

Usage

python3 src/dm-result.py "Result text"
python3 src/dm-result.py --file results/task-123.txt

Test plan

  • Tested with voice disconnected — DM arrives in Discord
  • Verified /sse-status endpoint returns voiceConnected field

🤖 Generated with Claude Code

Adds src/dm-result.py — checks voice client connection via /sse-status.
If voice is disconnected, sends the result to owner's Discord DM instead.

Usage:
  python3 src/dm-result.py "Result text"
  python3 src/dm-result.py --file results/task-123.txt

Reads DISCORD_BOT_TOKEN from ~/.claude/channels/discord/.env.
Truncates at 1900 chars for Discord's 2000-char limit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sonichi
Copy link
Copy Markdown
Owner Author

sonichi commented Apr 15, 2026

Cross-review from Sutando-Mini (per owner request). LGTM — merge it.

Read the whole 113-line diff end-to-end. Functional and safe. Manual testing confirmed it works (owner got the test DM).

Minor notes (none blocking)

  1. Doc ↔ code mismatch on the env var name: the module docstring says Requires DISCORD_TOKEN in .env, but the code looks for DISCORD_BOT_TOKEN=. Just a comment typo — the code is the source of truth.

  2. Hardcoded DM channel ID (DM_CHANNEL = "1485370959870431433"): fine for single-owner use but doesn't survive if the owner changes Discord accounts or if we ever want multi-owner setups. Could be read from ~/.claude/channels/discord/access.json or similar. Not urgent — that file format doesn't currently store a DM channel.

  3. voice_connected() → False on any exception: web-client down is indistinguishable from voice-disconnected. Both trigger the DM fallback. That's the conservative choice (the DM is the fallback, so when in doubt, fall back) — but worth knowing: a web-client crash will route everything to Discord until someone restarts it.

  4. Wiring: this PR adds the script but doesn't wire it into the task-result flow. Follow-up work — src/task-bridge.ts (or discord-bridge.py) should call python3 src/dm-result.py --file results/task-*.txt when writing results, gated on the voice-connected check. Otherwise this is a manual tool.

What I verified

  • Token loading: reads first match across ~/.claude/channels/discord/.env./.env. Correct precedence.
  • Truncation at 1900 chars matches Discord's 2000 limit with 100 char margin for the "... (truncated)" suffix. ✓
  • Timeouts (2s status, 10s send) are sane.
  • No shell execution, no subprocess calls (despite the subprocess import — it's unused here; dead import).
  • POST /channels/{id}/messages with Authorization: Bot ... is the correct Discord API path.

Dead import nit

Line 17: import subprocess is imported but never used. Safe to remove in a follow-up or squash.

Approve-equivalent comment. Merge when you want — I'd do the follow-up wiring as its own PR so this one stays at 113 lines of clean, reviewable scope.

@sonichi sonichi merged commit 376fd8a into main Apr 15, 2026
1 check passed
@sonichi sonichi deleted the feat/dm-result-fallback branch April 15, 2026 20:52
sonichi pushed a commit that referenced this pull request Apr 15, 2026
Follow-up to PR #347. PR #347 shipped `src/dm-result.py` as a standalone
CLI but didn't wire it into any polling loop, so voice-originated and
cron-originated results that no one handles would still be silently
dropped when the voice client is offline.

Adds `poll_dm_fallback()` — a 4th asyncio task registered in `on_ready`
alongside `poll_results`, `poll_approved`, and `poll_proactive`. Scans
`results/` every 30s for task/question/briefing/insight/friction files
that are:

  - not in `pending_replies` (Discord-originated, already handled)
  - older than 90s (grace period so voice-agent and telegram-bridge get
    first dibs)

and subprocess-calls `python3 src/dm-result.py --file <f>`. Delegating
to the CLI keeps the voiceConnected check + Discord-DM-send logic in one
place (the script MacBook shipped in #347). If dm-result.py prints
"voice client connected, skipping DM" the file is left on disk for the
voice agent to speak when a client reconnects. On any other non-zero
exit, the error is logged and the file is also left on disk for the
next retry cycle.

Also: drop unused `import subprocess` from `src/dm-result.py` — flagged
in my cross-review on #347 (comment 4255378452). Harmless but noted.

### Known limitation (not introduced by this PR)
Tested end-to-end on Mac Mini: the subprocess call reaches Discord but
returns HTTP 403. dm-result.py hardcodes `DM_CHANNEL = "1485370959870431433"`
which corresponds to MacBook's bot's DM channel with the owner; the
Mac Mini bot doesn't have permission to POST to that channel. Flagged
in my original #347 review — the fix is either a per-node config file
or having the bot open its own DM channel on demand. Out of scope for
this PR; the wiring is correct and will work once the DM channel is
per-node.

### Verification
- `ast.parse` clean on both files
- Subprocess smoke test: loop correctly subprocesses out, reads rc, logs
  stderr when dm-result.py fails. Fake stale file cleaned up after.
- No changes to existing `poll_results` / `poll_proactive` behavior —
  race-free because `poll_dm_fallback` excludes `pending_replies` task
  IDs and only touches files ≥90s old.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request Apr 15, 2026
#349)

* discord-bridge: wire dm-result.py into result-poll flow as DM fallback

Follow-up to PR #347. PR #347 shipped `src/dm-result.py` as a standalone
CLI but didn't wire it into any polling loop, so voice-originated and
cron-originated results that no one handles would still be silently
dropped when the voice client is offline.

Adds `poll_dm_fallback()` — a 4th asyncio task registered in `on_ready`
alongside `poll_results`, `poll_approved`, and `poll_proactive`. Scans
`results/` every 30s for task/question/briefing/insight/friction files
that are:

  - not in `pending_replies` (Discord-originated, already handled)
  - older than 90s (grace period so voice-agent and telegram-bridge get
    first dibs)

and subprocess-calls `python3 src/dm-result.py --file <f>`. Delegating
to the CLI keeps the voiceConnected check + Discord-DM-send logic in one
place (the script MacBook shipped in #347). If dm-result.py prints
"voice client connected, skipping DM" the file is left on disk for the
voice agent to speak when a client reconnects. On any other non-zero
exit, the error is logged and the file is also left on disk for the
next retry cycle.

Also: drop unused `import subprocess` from `src/dm-result.py` — flagged
in my cross-review on #347 (comment 4255378452). Harmless but noted.

### Known limitation (not introduced by this PR)
Tested end-to-end on Mac Mini: the subprocess call reaches Discord but
returns HTTP 403. dm-result.py hardcodes `DM_CHANNEL = "1485370959870431433"`
which corresponds to MacBook's bot's DM channel with the owner; the
Mac Mini bot doesn't have permission to POST to that channel. Flagged
in my original #347 review — the fix is either a per-node config file
or having the bot open its own DM channel on demand. Out of scope for
this PR; the wiring is correct and will work once the DM channel is
per-node.

### Verification
- `ast.parse` clean on both files
- Subprocess smoke test: loop correctly subprocesses out, reads rc, logs
  stderr when dm-result.py fails. Fake stale file cleaned up after.
- No changes to existing `poll_results` / `poll_proactive` behavior —
  race-free because `poll_dm_fallback` excludes `pending_replies` task
  IDs and only touches files ≥90s old.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* dm-result: drop hardcoded DM_CHANNEL, open per-node DM via Discord API

Addresses the hardcode owner flagged after PR #349 opened.

Previously `DM_CHANNEL = "1485370959870431433"` was baked into dm-result.py.
That channel belonged to a specific bot's DM with the owner, so
subprocess calls from a different node hit HTTP 403. Confirmed
end-to-end on Mac Mini during smoke-testing of PR #349.

Now the DM channel is resolved on demand:

1. `_resolve_owner_id()` picks the human owner by:
   a. `$SUTANDO_DM_OWNER_ID` env var (explicit override, skips API calls)
   b. First non-bot entry in `~/.claude/channels/discord/access.json`
      allowFrom, decided by a GET /users/{id} `bot` flag. allowFrom
      typically contains multiple bot accounts (MacBook bot, Mac Mini
      bot) plus the human owner — without the is-bot check we'd DM the
      wrong bot.

2. `_open_dm_channel()` calls `POST /users/@me/channels` with the
   resolved owner's user ID. Discord docs are explicit that this
   endpoint is idempotent — returns the existing DM channel if one
   exists, otherwise creates one. Cheap to call per invocation.

3. `_discord_api()` extracted as a small wrapper so the GET /users/@me,
   POST /users/@me/channels, POST /channels/{id}/messages paths all share
   a single urllib+auth call site. Side effect: send_dm() is ~40% shorter
   and easier to follow.

### Smoke test results

Against live Discord API on Mac Mini:
- `_resolve_owner_id` correctly returns `1022910063620390932` (sonichi)
  by skipping `1485364006297534584` (MacBook bot) via is-bot lookup.
- `_open_dm_channel` returns `1490906927675474030` — a live DM channel,
  completely different from the old hardcoded value.
- Actual send deferred to avoid spamming owner during testing. The POST
  path is standard Discord API and is the same byte sequence as before
  the refactor; only the channel ID input changed.

### Python 3.9 note

The `dict | None = None` union-syntax type hint crashed at module import
on the system Python 3.9 (`TypeError: unsupported operand | for type`).
Switched to untyped function signatures for the helpers — docstrings
carry the same information. Caught in the first smoke-test run.

### Bot ID also no longer needs to be known

The previous draft of this change used GET /users/@me to discover the
current bot's user ID and skip it in allowFrom. Replaced with per-user
is-bot lookup, which correctly skips all bots (not just the current
one) in multi-bot allowFrom lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chi <wangchi@Chis-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant